JavaScript 中的 this 关键字是设计失误了还是一个未完成的半成品特性?

作者:谷雨同学
链接:https://www.zhihu.com/question/654178975/answer/3480674806
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

算不上设计失误,只是早期 JS 设计得过于灵活。

当我们说编程语言中的 this 或者 self,我们在说什么?其实我们在指定一种特殊的函数调用形式——中缀调用形式。出于直观,通常的函数调用形式是前缀的,即函数名出现在所有实参之前:

f(a, b, c)

几乎所有的编程语言都这样设计。但是面向对象编程范式的发明使人们发现,中缀调用形式具有更好的可读性:

a.f(b, c)

即把函数名放在第一个参数和剩余参数中间。尤其是 f 是某种和 a 相关的操作的时候,这种写法非常好看好懂(否则可以参考 C 中的面向对象的那些库的设计,各种 xxx_foo(&xxx, ...),看着很费劲)。

那接下来的问题就是,当使用中缀调用形式调用一个函数时,是否需要将这个“被提前到函数名之前的实参”用一个特殊的形参名来指示?C++ 和 Java 选择回答“是”。于是,所有可以被中缀调用的函数(C++ 中的非静态成员函数、Java 中的非静态方法),都通过 this 关键字来访问这个特殊形参。

void A::foo(B b, C c) {
    // 收到的三个实参分别是 *this, b, c
    std::println("{}, {}, {}", *this, b, c);
}
int main() {
    A a; B b; C c;
    a.A::foo(b, c); // 中缀调用 A::foo。
}

Python 对这个问题回答“否”。所有可以被中缀调用的函数,其首个形参就是被提前在函数名之前的那个实参。

class A:
    def foo(a, b, c):
        print(a, b, c)
        
if __name__ == "__main__":
    a = A(); b = B(); c = C()
    a.foo(b, c)

JavaScript 是跟风 Java 设计的,所以肯定也引入了 this。在大部分的使用场景下,这是一点问题没有的:

class A {
    foo(b, c) {
        console.log(this, b, c);
    }
}
const a = new A(), b = new B(), c = new C();
a.foo(b, c); // very nice.

但是问题出在,JS 是动态类型的;如果不去用中缀调用形式调用 a.foo,那就有些混乱了。

const bar = A.prototype.foo;
bar(b, c); // So what about `this`?

这个时候 bar ,也就是原先 A 类里的 foo 函数,缺少了本应出现在 foo 左侧的首个实参 a,而它是需要传给形参 this 的。失去中缀调用形式后,foothis 就只能指向一些其他的东西了,比如 globalThis,或者严格模式下的 undefined

顺便一提,你只要给这个 bar 再以中缀调用的形式调用一下,它就能得到一个 this 值了。

var bar = A.prototype.foo; // var 声明为 globalThis 增加一个属性
globalThis.bar(b, c);      // OK, this = globalThis

一言以蔽之,JS 的 this 的混乱单纯是因为,我有一个期望中缀调用的函数,但你偏偏不去中缀调用导致的。

其他编程语言有这个问题吗?首先那些在之前问题中回答“否”的编程语言不会有;因为在他们眼中,中缀调用不过是普通的前缀调用的“语法糖”罢了,没有什么特殊的形参语法

class A:
    def foo(a, b, c):
        print(a, b, c)
        
if __name__ == "__main__":
    a = A(); b = B(); c = C()
    bar = A.foo
#   bar(b, c)    # Error: 参数数量不对
    bar(a, b, c) # OK

其次,静态类型语言不会有这个问题,这些期望中缀调用的函数有独特的类型,不能使用非中缀的语法使用。

void A::foo(B b, C c) {
    std::println("{}, {}, {}", *this, b, c);
}
int main() {
    A a; B b; C c;
    auto bar = &A::foo; // OK, 成员函数指针
//  bar(b, c);          // Error, 不可以非中缀调用成员函数
    a.*bar(b, c);       // OK, 必须有一个前置实参
}

最后,其它那些用隐式 this 的动态类型语言,大多禁止了“非中缀调用”成员函数的语法(比如 a.foo 或者 A.foo 后面必须跟着括号立即调用,不能作为其他表达式的操作数)。所以这个问题就似乎只在 JS 上存在,并且被那些培训机构和语言小鬼津津乐道。


最后扯个题外话,把中缀调用形式做到极致的还得是 Smalltalk 系语言,比如我们看一下 Objective-C 选手的做法:

@interface A {}
- (void)fooWithB: (B)b andC: (C)c;
@end

int main() {
    A *a = [A new]; B b; C c;
    [a fooWithB: b andC: c];   // WTF?
}

这里被调用的函数名是 fooWithB:andC, 直接拆到所有参数的中间去……ab 中间夹着 fooWithB,然后 bc 中间夹着 andC,将《中缀》表现得淋漓尽致,嗯。